package com.csaba.util.encryption;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.ObjectInputStream;
import java.io.ObjectOutputStream;
import java.io.Serializable;
import java.security.GeneralSecurityException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.PBEKeySpec;
import javax.crypto.spec.PBEParameterSpec;

import com.csaba.util.Base64Coder;
import com.csaba.util.LoggerHelper;

//FIXME doc
public class EncryptedStore
{
	private final static Logger logger = LoggerHelper.getDefaultLogger();

	private static EncryptedStore instance = new EncryptedStore();

	public static EncryptedStore getInstance()
	{
		return instance;
	}

	public final static void clear( final char[] password )
	{
		Arrays.fill(password, (char) 0);
	}

	public static void main( final String[] args ) throws Exception
	{
		LoggerHelper.initLogger(Level.FINE);
		logger.fine("Start test..."); //$NON-NLS-1$

		EncryptedStore store = new EncryptedStore();

		store.setPassword("kakimaki".toCharArray()); //$NON-NLS-1$

		store.putValue("/customer/CIBHU/123123/name", "Gyenes Gábor"); //$NON-NLS-1$ //$NON-NLS-2$
		store.putValue("/customer/CIBHU/123124/name", "Gyenes Gáborné"); //$NON-NLS-1$ //$NON-NLS-2$
		store.putValue("/customer/OTPHU/124124/name", "Kerekfejű Mityu"); //$NON-NLS-1$ //$NON-NLS-2$
		store.putValue("/signpassword/123123", "kiskutyafasza"); //$NON-NLS-1$ //$NON-NLS-2$

		store.debugKeys(Level.FINE);

		final String base64 = store.toBASE64String();
		System.out.println("BASE64:   " + base64); //$NON-NLS-1$

		store = new EncryptedStore();

		store.setContent(base64);
		store.setPassword("kakimaki".toCharArray()); //$NON-NLS-1$

		store.debugKeys(Level.FINE);

		final String[] keys = store.getKeysStartWith("/customer/"); //$NON-NLS-1$
		System.out.print(" Customers stored: "); //$NON-NLS-1$
		for ( final String key : keys )
		{
			System.out.print(key.substring("/customer/".length())); //$NON-NLS-1$
		}

		System.out.println();
	}

	private SecretKey pbeKey;
	private final PBEParameterSpec pbeParamSpec;

	private final HashMap<String, Serializable> registry = new HashMap<String, Serializable>();

	private String base64CipherText = null;

	private final List<EncryptedStoreListener> listeners = new LinkedList<EncryptedStoreListener>();

	private EncryptedStore()
	{
		// Salt
		final byte[] salt = { (byte) 0x43, (byte) 0x11, (byte) 0x01, (byte) 0x8c, (byte) 0x6a, (byte) 0xbe,
				(byte) 0x78, (byte) 0xcb };

		// Iteration count
		final int count = 20;

		// Create PBE parameter set
		pbeParamSpec = new PBEParameterSpec(salt, count);
	}

	public void addEncryptedStoreListener( final EncryptedStoreListener listener )
	{
		listeners.add(listener);
	}

	public void removeEncryptedStoreListener( final EncryptedStoreListener listener )
	{
		listeners.remove(listener);
	}

	private void fireEncryptedStoreEvent( final int eventType )
	{
		final EncryptedStoreEvent event = new EncryptedStoreEvent(this, eventType);
		for ( final EncryptedStoreListener listener : listeners )
		{
			listener.storeUpdated(event);
		}
	}

	public void changePassword( final char[] oldPassword, final char[] newPassword ) throws GeneralSecurityException
	{
		checkOpen();

		final SecretKeyFactory keyFac = SecretKeyFactory.getInstance("PBEWithMD5AndDES"); //$NON-NLS-1$

		//check whether old password is correct
		final PBEKeySpec oldKeySpec = new PBEKeySpec(oldPassword);
		final SecretKey oldKey = keyFac.generateSecret(oldKeySpec);

		if ( !oldKey.equals(pbeKey) )
			throw new GeneralSecurityException("The old password is not correct.");

		// convert password it into a SecretKey object, using a PBE key factory.
		final PBEKeySpec pbeKeySpec = new PBEKeySpec(newPassword);
		pbeKey = keyFac.generateSecret(pbeKeySpec);

		fireEncryptedStoreEvent(EncryptedStoreEvent.STORE_PASSWORD_CHANGED);
	}

	private void checkOpen()
	{
		if ( !isOpen() )
			throw new IllegalStateException("The EncryptionStore is not open. No password is set yet.");
	}

	public void debugKeys( final Level level )
	{
		checkOpen();

		if ( logger.isLoggable(level) )
		{
			logger.log(level, "Encryption Store Key list starts ----------------"); //$NON-NLS-1$
			for ( final String key : registry.keySet() )
			{
				logger.log(level, key);
			}
			logger.log(level, "------------------ Encryption Store Key list ends"); //$NON-NLS-1$
		}
	}

	private byte[] decrypt( final byte[] cipherText ) throws GeneralSecurityException
	{
		checkOpen();

		//Create PBE Cipher
		final Cipher pbeCipher = Cipher.getInstance("PBEWithMD5AndDES"); //$NON-NLS-1$

		// Initialize PBE Cipher with key and parameters
		pbeCipher.init(Cipher.DECRYPT_MODE, pbeKey, pbeParamSpec);

		// Encrypt the cleartext
		return pbeCipher.doFinal(cipherText);
	}

	private void decryptContent() throws IOException, GeneralSecurityException, ClassNotFoundException
	{
		registry.clear();

		if ( base64CipherText == null || base64CipherText.length() == 0 )
		{
			base64CipherText = null;
			return;
		}

		final byte[] cipherText = Base64Coder.decode(base64CipherText);
		final byte[] clearText = decrypt(cipherText);

		final ByteArrayInputStream bais = new ByteArrayInputStream(clearText);
		final ObjectInputStream ois = new ObjectInputStream(bais);
		final String className = (String) ois.readObject();
		if ( !className.equals(getClass().getName()) )
			throw new GeneralSecurityException("Invalid content in EncryptedStore: " + className);
		while ( bais.available() > 0 )
		{
			final String key = (String) ois.readObject();
			final Object value = ois.readObject();
			registry.put(key, (Serializable) value);
		}
		ois.close();

		base64CipherText = null;
	}

	private byte[] encrypt( final byte[] clearText ) throws GeneralSecurityException
	{
		checkOpen();

		// Create PBE Cipher
		final Cipher pbeCipher = Cipher.getInstance("PBEWithMD5AndDES"); //$NON-NLS-1$

		// Initialize PBE Cipher with key and parameters
		pbeCipher.init(Cipher.ENCRYPT_MODE, pbeKey, pbeParamSpec);

		// Encrypt the cleartext
		return pbeCipher.doFinal(clearText);
	}

	public String[] getKeysStartWith( final String keyStart )
	{
		checkOpen();

		final List<String> keys = new ArrayList<String>();
		for ( final String key : registry.keySet() )
		{
			if ( key.startsWith(keyStart) )
				keys.add(key);
		}
		return keys.toArray(new String[keys.size()]);
	}

	public Serializable getValue( final String key )
	{
		checkOpen();

		return registry.get(key);
	}

	public boolean isEmpty()
	{
		if ( base64CipherText != null && base64CipherText.length() == 0 )
			return true; // empty  store where the password is not yet set

		if ( base64CipherText == null && registry.size() == 0 )
			return true; // totally uninitialized, or password is set, but no key is set yet.

		return false;
	}

	/**
	 * Indicates whether the EncryptedStore is already initialized with password. 
	 * It doesn't mean that the password is set currently and the store is open, 
	 * but only means that once it was set, and the store can be open.
	 * @return true, if the password was ever set in the store.
	 */
	public boolean isInitialized()
	{
		if ( isOpen() )
			return true;

		return ( base64CipherText != null && base64CipherText.length() > 0 );
	}

	/**
	 * Indicates that the EncryptedStore is openned with the current password, and registry keys can be read.
	 * @return
	 */
	public boolean isOpen()
	{
		return pbeKey != null;
	}

	public void putValue( final String key, final Serializable value )
	{
		checkOpen();

		registry.put(key, value);
		fireEncryptedStoreEvent(EncryptedStoreEvent.STORE_UPDATED);

	}

	public void setContent( final String base64CipherText ) throws IOException, GeneralSecurityException,
			ClassNotFoundException
	{
		this.base64CipherText = base64CipherText;
		fireEncryptedStoreEvent(EncryptedStoreEvent.STORE_DESERIALIZED);

		if ( pbeKey != null )
			decryptContent();
	}

	public void setPassword( final char[] password ) throws GeneralSecurityException, IOException,
			ClassNotFoundException
	{
		if ( isOpen() )
			new IllegalStateException("The EncryptedStore is already initialized. ");

		// convert password it into a SecretKey object, using a PBE key factory.
		final PBEKeySpec pbeKeySpec = new PBEKeySpec(password);
		final SecretKeyFactory keyFac = SecretKeyFactory.getInstance("PBEWithMD5AndDES"); //$NON-NLS-1$
		pbeKey = keyFac.generateSecret(pbeKeySpec);

		final boolean isInit = base64CipherText == null || base64CipherText.length() == 0;
		try
		{
			decryptContent();
		}
		catch ( final Exception ex )
		{
			pbeKey = null;
			throw new GeneralSecurityException("Invalid password.", ex);
		}
		if ( isInit )
			fireEncryptedStoreEvent(EncryptedStoreEvent.STORE_INITIALIZED);
		fireEncryptedStoreEvent(EncryptedStoreEvent.STORE_OPEN);

	}

	public String toBASE64String() throws IOException, GeneralSecurityException
	{

		if ( pbeKey == null )
		{
			if ( base64CipherText != null )
				return base64CipherText;

			if ( registry.size() > 0 )
				throw new IllegalStateException("No password is set yet in the EncrpytedStore. Cannot serialize.");
			else
				return ""; //$NON-NLS-1$
		}

		final ByteArrayOutputStream baos = new ByteArrayOutputStream();
		final ObjectOutputStream oos = new ObjectOutputStream(baos);

		oos.writeObject(getClass().getName());

		for ( final String key : registry.keySet() )
		{
			oos.writeObject(key);
			oos.writeObject(registry.get(key));
		}
		oos.close();
		final byte[] clearText = baos.toByteArray();
		final byte[] cipherText = encrypt(clearText);

		final String result = new String(Base64Coder.encode(cipherText));

		fireEncryptedStoreEvent(EncryptedStoreEvent.STORE_SERIALIZED);

		return result;
	}
}
